agentmux_srv\backend\storage/dual_write.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase 3a — `db_agents` dual-write helpers.
5//!
6//! Every mutation on `db_agent_definitions` / `db_agent_instances`
7//! mirrors into `db_agents` so a later read-migration PR (Phase 3b)
8//! can flip readers over with confidence. Dual-write failures LOG +
9//! CONTINUE — the old tables remain authoritative until Phase 3b.
10//! See `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
11//!
12//! Extracted from `store.rs` in Phase R.5 of the storage
13//! modularization plan
14//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). This
15//! whole file goes away in Phase 3c when the legacy tables are
16//! dropped — keeping it isolated makes that future deletion clean.
17
18use rusqlite::{params, Connection};
19
20use super::error::StoreError;
21use super::store::{AgentDefinition, AgentInstance, Store};
22
23impl Store {
24 /// Mirror a `db_agent_definitions` row into `db_agents` as the
25 /// canonical row for that definition.
26 ///
27 /// - `is_seeded = 1` rows become `is_template = 1` (canonical
28 /// template; bindings stay empty).
29 /// - `is_seeded = 0` rows become `is_template = 0` with
30 /// `parent_template_id = parent_id` (user-cloned from a template;
31 /// bindings come from the matching instance, if any — handled by
32 /// `agents_dual_write_instance_create` updating in place).
33 ///
34 /// Idempotent: uses `INSERT … ON CONFLICT(id) DO UPDATE`. Existing
35 /// bindings on the row (written previously by an instance dual-write)
36 /// are preserved — only definition-side fields are overwritten.
37 ///
38 /// Phase 3b: returns `Err` on failure (previously logged + continued).
39 /// Phase 3b readers see `db_agents`, so a silent dual-write failure
40 /// would leak stale data into the picker.
41 pub(crate) fn agents_dual_write_definition_upsert(
42 &self,
43 def: &AgentDefinition,
44 ) -> Result<(), StoreError> {
45 let conn = self.conn.lock().unwrap();
46 let is_template = if def.is_seeded == 1 { 1_i64 } else { 0_i64 };
47 let parent_template_id = if def.is_seeded == 1 {
48 String::new()
49 } else {
50 def.parent_id.clone()
51 };
52 // Phase 3b: carry the caller's `user_hidden` into the INSERT
53 // (previously hardcoded to 0). The legacy `db_agent_definitions`
54 // INSERT honours it, and now that Phase 3b readers look at
55 // `db_agents`, the projection must agree from row creation —
56 // not just after a subsequent `agent_def_set_hidden` flip.
57 // ON CONFLICT deliberately does NOT update `user_hidden`: hide
58 // is a per-user view-state flag that survives definition
59 // payload edits.
60 conn.execute(
61 "INSERT INTO db_agents (
62 id, name, icon, description,
63 is_template, parent_template_id,
64 provider, provider_flags, shell, environment,
65 agent_type, agent_bus_id, accounts,
66 auto_start, restart_on_crash, idle_timeout_minutes,
67 slug, branch_label, working_directory,
68 created_at, updated_at, is_seeded, user_hidden,
69 container_image, container_volumes, container_name
70 ) VALUES (
71 ?1, ?2, ?3, ?4,
72 ?5, ?6,
73 ?7, ?8, ?9, ?10,
74 ?11, ?12, ?13,
75 ?14, ?15, ?16,
76 ?17, ?18, ?19,
77 ?20, ?21, ?22, ?23,
78 ?24, ?25, ?26
79 )
80 ON CONFLICT(id) DO UPDATE SET
81 name = excluded.name,
82 icon = excluded.icon,
83 description = excluded.description,
84 is_template = excluded.is_template,
85 parent_template_id = excluded.parent_template_id,
86 provider = excluded.provider,
87 provider_flags = excluded.provider_flags,
88 shell = excluded.shell,
89 environment = excluded.environment,
90 agent_type = excluded.agent_type,
91 agent_bus_id = excluded.agent_bus_id,
92 accounts = excluded.accounts,
93 auto_start = excluded.auto_start,
94 restart_on_crash = excluded.restart_on_crash,
95 idle_timeout_minutes = excluded.idle_timeout_minutes,
96 slug = excluded.slug,
97 branch_label = excluded.branch_label,
98 working_directory = excluded.working_directory,
99 updated_at = excluded.updated_at,
100 is_seeded = excluded.is_seeded,
101 container_image = excluded.container_image,
102 container_volumes = excluded.container_volumes,
103 container_name = excluded.container_name",
104 params![
105 def.id,
106 def.name,
107 def.icon,
108 def.description,
109 is_template,
110 parent_template_id,
111 def.provider,
112 def.provider_flags,
113 def.shell,
114 def.environment,
115 def.agent_type,
116 def.agent_bus_id,
117 def.accounts,
118 def.auto_start,
119 def.restart_on_crash,
120 def.idle_timeout_minutes,
121 def.slug,
122 def.branch_label,
123 def.working_directory,
124 def.created_at,
125 def.updated_at,
126 def.is_seeded,
127 def.user_hidden,
128 def.container_image,
129 def.container_volumes,
130 def.container_name,
131 ],
132 )?;
133 Ok(())
134 }
135
136 /// Mirror a `db_agent_definitions` DELETE into `db_agents`. The
137 /// definition row itself is removed; any user-cloned children (rows
138 /// with `parent_template_id = old_id`) are left intact because the
139 /// FK cascade on the OLD schema only deletes instances, not other
140 /// definitions.
141 pub(crate) fn agents_dual_write_definition_delete(
142 &self,
143 def_id: &str,
144 ) -> Result<(), StoreError> {
145 let conn = self.conn.lock().unwrap();
146 // Reagent P2 on #1013 round 3: `id` is the PK so two DELETE
147 // statements scoped by `id = ?1 AND is_template = N` add nothing
148 // over a single PK delete (only one row can match either, and
149 // an early return on the first error would skip the second
150 // cleanup unnecessarily). Collapsed to a single direct PK
151 // delete that handles both template and user-clone projections.
152 conn.execute("DELETE FROM db_agents WHERE id = ?1", params![def_id])?;
153 Ok(())
154 }
155
156 /// Bulk dual-write: mirror `agent_def_delete_seeded`. Deletes:
157 /// 1. every `is_template = 1` row (the template projections), AND
158 /// 2. every `db_agents` row whose `id` is in the
159 /// `cascaded_inst_ids` set (template-instance projections that
160 /// were just removed by the FK cascade on `db_agent_instances`).
161 ///
162 /// User-clone DEFINITION projections (`is_template = 0`, `id` is a
163 /// def_id in `db_agent_definitions`) are NOT touched — those rows
164 /// represent persistent user agents and live or die with their
165 /// `db_agent_definitions` row, not with the seeded-template bulk
166 /// delete. Reagent P1 round 4 on #1013: the previous version
167 /// scoped by `parent_template_id` and over-deleted user-clone DEF
168 /// projections too. Idempotent.
169 pub(crate) fn agents_dual_write_seeded_delete(
170 &self,
171 cascaded_inst_ids: &[String],
172 ) -> Result<(), StoreError> {
173 let conn = self.conn.lock().unwrap();
174 conn.execute("DELETE FROM db_agents WHERE is_template = 1", [])?;
175 // Delete cascaded instance projections one by one. Could batch
176 // via `id IN (?1, ?2, ...)` but the seeded delete is rare and
177 // the typical set is small (< 100); per-id loop keeps the SQL
178 // simple and avoids dynamic-parameter expansion.
179 for inst_id in cascaded_inst_ids {
180 conn.execute(
181 "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
182 params![inst_id],
183 )?;
184 }
185 Ok(())
186 }
187
188 /// Mirror a `db_agent_instances` INSERT into `db_agents`. Always
189 /// creates a NEW row in `db_agents` whose id == instance id.
190 ///
191 /// The row's identity comes from the instance (id, name, bindings).
192 /// Its template-config fields are copied from the parent definition;
193 /// `parent_template_id` points at that definition.
194 ///
195 /// Continuations (`parent_instance_id` non-empty) mirror their
196 /// new bindings into the chain's existing `db_agents` row rather
197 /// than creating a separate one. The chain root is resolved via
198 /// `agents_projection_key_for_inst`. Codex P2 on PR #1110 — without
199 /// this, a named-agent rebind (continue with different identity,
200 /// memory, cwd, or github context) never reaches the consolidated
201 /// row and Phase 3b readers see stale data.
202 pub(crate) fn agents_dual_write_instance_create(
203 &self,
204 inst: &AgentInstance,
205 ) -> Result<(), StoreError> {
206 let is_continuation = !inst.parent_instance_id.is_empty();
207 let conn = self.conn.lock().unwrap();
208 // Pull the parent definition for the cmd-config fields.
209 let def = match Self::load_definition_for_dual_write(&conn, &inst.definition_id)? {
210 Some(d) => d,
211 None => {
212 // Orphan instance — no matching definition. The legacy
213 // table accepted the row (no FK in the old test stores),
214 // but with nothing to project there's nothing this
215 // helper can mirror. Surfacing this as Err would block
216 // a write the legacy path tolerated; log at error level
217 // and continue with no projection.
218 tracing::error!(
219 instance_id = %inst.id,
220 definition_id = %inst.definition_id,
221 "db_agents dual-write: instance has no matching definition; skipping mirror",
222 );
223 return Ok(());
224 }
225 };
226 let name = if inst.instance_name.is_empty() {
227 def.name.clone()
228 } else {
229 inst.instance_name.clone()
230 };
231 // Reagent P1 on #1013 round 2: match the backfill rule
232 // (`agents_consolidate.rs::backfill_instances`) exactly. When the
233 // parent def is a user-clone (`is_seeded = 0`), the existing
234 // `db_agents` row keyed by `def.id` already represents this
235 // agent — FOLD the instance's bindings into it instead of
236 // inserting a new row keyed by `inst.id` with
237 // `parent_template_id = def.id` (which would point at a
238 // non-template row and produce a shape inconsistent with what
239 // backfill produced for identical data).
240 // Monotonic-bump helper. See the original comment below for
241 // the full reasoning; extracted into a closure so both the
242 // user-clone path and the new continuation-mirror path can
243 // reuse it. Successive fast-fire launches (e.g. test loops on
244 // Windows millisecond-resolution clocks) need the strict
245 // global ordering to survive ORDER BY updated_at.
246 //
247 // Reagent P2 round 3 on #1013: don't write `inst.created_at`
248 // here — the user-clone def may already have a fresher
249 // `updated_at` from a prior `agent_def_update`. Wall-clock
250 // now() is the right monotonic stamp.
251 let now_ms = {
252 let wall_now: i64 = std::time::SystemTime::now()
253 .duration_since(std::time::UNIX_EPOCH)
254 .map(|d| d.as_millis() as i64)
255 .unwrap_or(inst.created_at);
256 let global_prior: i64 = conn
257 .query_row(
258 "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
259 [],
260 |row| row.get::<_, i64>(0),
261 )
262 .unwrap_or(0);
263 std::cmp::max(wall_now, global_prior.saturating_add(1))
264 };
265
266 let res = if def.is_seeded == 0 {
267 // User-clone def — one db_agents row per def, keyed by
268 // def.id. Continuations and head launches both UPDATE the
269 // same row. (The chain's identity has nothing to do with
270 // which row gets touched here; it's always def.id.)
271 conn.execute(
272 "UPDATE db_agents SET
273 name = ?2,
274 identity_id = ?3,
275 memory_id = ?4,
276 working_directory = ?5,
277 github_context = ?6,
278 instance_name = ?7,
279 updated_at = ?8,
280 user_hidden = ?9,
281 last_block_id = ?10
282 WHERE id = ?1",
283 params![
284 def.id,
285 name,
286 inst.identity_id,
287 inst.memory_id,
288 def.working_directory,
289 inst.github_context,
290 inst.instance_name,
291 now_ms,
292 if inst.display_hidden { 1_i64 } else { 0_i64 },
293 inst.block_id,
294 ],
295 )
296 } else if is_continuation {
297 // Template-instance continuation — UPDATE the chain head's
298 // row with the new bindings. The head's id is found via
299 // `agents_projection_key_for_inst` (which walks parent
300 // _instance_id up to the root). No new row is created;
301 // there's exactly one db_agents row per logical agent.
302 // Codex P2 on PR #1110.
303 let root_id = match Self::agents_projection_key_for_inst(&conn, &inst.id) {
304 Some((k, _)) => k,
305 None => {
306 tracing::warn!(
307 instance_id = %inst.id,
308 parent_instance_id = %inst.parent_instance_id,
309 "db_agents dual-write: continuation chain has no resolvable root; skipping mirror",
310 );
311 return Ok(());
312 }
313 };
314 conn.execute(
315 "UPDATE db_agents SET
316 name = ?2,
317 identity_id = ?3,
318 memory_id = ?4,
319 working_directory = ?5,
320 github_context = ?6,
321 instance_name = ?7,
322 updated_at = ?8,
323 user_hidden = ?9,
324 last_block_id = ?10
325 WHERE id = ?1 AND is_template = 0",
326 params![
327 root_id,
328 name,
329 inst.identity_id,
330 inst.memory_id,
331 def.working_directory,
332 inst.github_context,
333 inst.instance_name,
334 now_ms,
335 if inst.display_hidden { 1_i64 } else { 0_i64 },
336 inst.block_id,
337 ],
338 )
339 } else {
340 // Template-instance head — INSERT a new row keyed by inst.id.
341 conn.execute(
342 "INSERT INTO db_agents (
343 id, name, icon, description,
344 is_template, parent_template_id,
345 provider, provider_flags, shell, environment,
346 agent_type, agent_bus_id, accounts,
347 auto_start, restart_on_crash, idle_timeout_minutes,
348 slug, branch_label,
349 identity_id, memory_id, working_directory, github_context,
350 instance_name,
351 created_at, updated_at, is_seeded, user_hidden,
352 last_block_id,
353 container_image, container_volumes, container_name
354 ) VALUES (
355 ?1, ?2, ?3, ?4,
356 0, ?5,
357 ?6, ?7, ?8, ?9,
358 ?10, ?11, ?12,
359 ?13, ?14, ?15,
360 ?16, ?17,
361 ?18, ?19, ?20, ?21,
362 ?22,
363 ?23, ?24, 0, ?25,
364 ?26,
365 ?27, ?28, ?29
366 )
367 ON CONFLICT(id) DO UPDATE SET
368 name = excluded.name,
369 identity_id = excluded.identity_id,
370 memory_id = excluded.memory_id,
371 working_directory = excluded.working_directory,
372 github_context = excluded.github_context,
373 instance_name = excluded.instance_name,
374 updated_at = excluded.updated_at,
375 user_hidden = excluded.user_hidden,
376 last_block_id = excluded.last_block_id",
377 params![
378 inst.id,
379 name,
380 def.icon,
381 def.description,
382 def.id, // parent_template_id = definition_id
383 def.provider,
384 def.provider_flags,
385 def.shell,
386 def.environment,
387 def.agent_type,
388 def.agent_bus_id,
389 def.accounts,
390 def.auto_start,
391 def.restart_on_crash,
392 def.idle_timeout_minutes,
393 def.slug,
394 def.branch_label,
395 inst.identity_id,
396 inst.memory_id,
397 def.working_directory,
398 inst.github_context,
399 inst.instance_name,
400 inst.created_at,
401 inst.created_at, // updated_at = created_at on insert
402 if inst.display_hidden { 1_i64 } else { 0_i64 },
403 inst.block_id,
404 def.container_image,
405 def.container_volumes,
406 def.container_name,
407 ],
408 )
409 };
410 res?;
411 Ok(())
412 }
413
414 /// Mirror a `db_agent_instances` UPDATE into `db_agents`. Touches
415 /// only the fields that `instance_update` writes (block + session +
416 /// status + github_context + ended_at) — name/bindings come from
417 /// the original create.
418 ///
419 /// Continuations flow through here too — `agents_projection_key_for_inst`
420 /// walks up the chain to the head's id, so a continuation's
421 /// `github_context` refresh lands on the canonical row (codex P2 on
422 /// PR #1110, paired with the create-path fix in
423 /// `agents_dual_write_instance_create`).
424 pub(crate) fn agents_dual_write_instance_update(
425 &self,
426 inst: &AgentInstance,
427 ) -> Result<(), StoreError> {
428 let conn = self.conn.lock().unwrap();
429 let now = std::time::SystemTime::now()
430 .duration_since(std::time::UNIX_EPOCH)
431 .unwrap_or_default()
432 .as_millis() as i64;
433 // Reagent P1 round 4 on #1013: route by projection key so the
434 // UPDATE actually hits the folded user-clone-def row when this
435 // instance was folded at create. The previous version keyed by
436 // `inst.id` always and silently no-op'd on every folded
437 // instance's lifecycle event.
438 let key = match Self::agents_projection_key_for_inst(&conn, &inst.id) {
439 Some((k, _)) => k,
440 None => return Ok(()),
441 };
442 // `instance_update` only touches block_id/session_id/status/
443 // github_context/ended_at. Of those, only `github_context` lands
444 // on db_agents (block/session/status/ended_at are not modelled
445 // on the consolidated row — they're block/session-machine
446 // concerns the consolidation deliberately drops). We DO refresh
447 // updated_at so a Phase-3b reader can sort by recency. Apply
448 // the same monotonic-floor trick as the fold branch in
449 // `agents_dual_write_instance_create` — wall clock alone
450 // collides under millisecond resolution on fast successive
451 // mutations.
452 let global_prior: i64 = conn
453 .query_row(
454 "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
455 [],
456 |row| row.get::<_, i64>(0),
457 )
458 .unwrap_or(0);
459 let now_monotonic = std::cmp::max(now, global_prior.saturating_add(1));
460 conn.execute(
461 "UPDATE db_agents SET
462 github_context = ?1,
463 updated_at = ?2
464 WHERE id = ?3 AND is_template = 0",
465 params![inst.github_context, now_monotonic, key],
466 )?;
467 Ok(())
468 }
469
470 /// Mirror `instance_set_hidden` into `db_agents.user_hidden`.
471 pub(crate) fn agents_dual_write_instance_set_hidden(
472 &self,
473 id: &str,
474 hidden: bool,
475 ) -> Result<(), StoreError> {
476 let conn = self.conn.lock().unwrap();
477 // Reagent P1 round 4 on #1013: route by projection key (see
478 // `agents_dual_write_instance_update` for context).
479 let key = match Self::agents_projection_key_for_inst(&conn, id) {
480 Some((k, _)) => k,
481 None => return Ok(()),
482 };
483 conn.execute(
484 "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 0",
485 params![if hidden { 1_i64 } else { 0_i64 }, key],
486 )?;
487 Ok(())
488 }
489
490 /// Mirror `instance_repoint_definition` into `db_agents`. The
491 /// `parent_template_id` of every user-clone row that pointed at
492 /// `old_def_id` is updated to `new_def_id`.
493 pub(crate) fn agents_dual_write_instance_repoint(
494 &self,
495 old_def_id: &str,
496 new_def_id: &str,
497 ) -> Result<(), StoreError> {
498 let conn = self.conn.lock().unwrap();
499 conn.execute(
500 // Reagent P1 round 3 on #1013: `instance_repoint_definition`
501 // only rewrites `db_agent_instances.definition_id` — it
502 // does NOT rewrite the parent of a sibling user-cloned
503 // definition that happens to share the same parent template.
504 // Restrict the projection update to rows whose `id` is an
505 // ACTUALLY repointed instance id (i.e. the rows whose
506 // `db_agent_instances.definition_id` was just rewritten to
507 // `new_def_id`). User-clone definition projections, whose
508 // `id` lives in `db_agent_definitions` not `db_agent_instances`,
509 // are untouched.
510 "UPDATE db_agents
511 SET parent_template_id = ?1
512 WHERE is_template = 0
513 AND parent_template_id = ?2
514 AND id IN (SELECT id FROM db_agent_instances WHERE definition_id = ?1)",
515 params![new_def_id, old_def_id],
516 )?;
517 Ok(())
518 }
519
520 /// Mirror `instance_delete` into `db_agents`.
521 pub(crate) fn agents_dual_write_instance_delete(&self, id: &str) -> Result<(), StoreError> {
522 let conn = self.conn.lock().unwrap();
523 // Reagent P1 round 4 on #1013: route by projection key. For a
524 // template-instance projection (`is_folded = false`), the row
525 // lives at `id` and goes when the instance goes. For a
526 // user-clone-def fold (`is_folded = true`), the projection at
527 // `def.id` represents the DEF — the def persists when its
528 // instance ends, so NO-OP. `agents_dual_write_definition_delete`
529 // is the right entry point to remove that row.
530 //
531 // Race fallback: `instance_delete` runs the DELETE on
532 // `db_agent_instances` BEFORE calling this helper, so by now
533 // the instance row is gone and `agents_projection_key_for_inst`
534 // returns None. Fall back to checking `db_agents` directly: if
535 // there's a row at `id` with `is_template = 0`, that's a
536 // non-folded projection — delete it. (Folded projections live
537 // at `def.id`, never at `inst.id`, so this is safe.)
538 let key_info = Self::agents_projection_key_for_inst(&conn, id);
539 let (key, is_folded) = match key_info {
540 Some(info) => info,
541 None => (id.to_string(), false),
542 };
543 if is_folded {
544 return Ok(());
545 }
546 conn.execute(
547 "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
548 params![key],
549 )?;
550 Ok(())
551 }
552
553 /// Mirror `instance_backfill_identity_id` into `db_agents`. Same
554 /// filter (empty or `"blank"` identity_id) restricted to user-clone
555 /// rows.
556 pub(crate) fn agents_dual_write_backfill_identity(
557 &self,
558 new_identity_id: &str,
559 ) -> Result<(), StoreError> {
560 let conn = self.conn.lock().unwrap();
561 conn.execute(
562 "UPDATE db_agents
563 SET identity_id = ?1
564 WHERE is_template = 0 AND (identity_id = '' OR identity_id = 'blank')",
565 params![new_identity_id],
566 )?;
567 Ok(())
568 }
569
570 /// Reagent P1 round 4 on #1013 — fold-aware projection key lookup.
571 /// Resolves "for an instance with this id, which `db_agents` row
572 /// represents it post-create?". Returns `Some((key, is_folded))`:
573 /// - `is_folded = true` → key is the parent definition id
574 /// (the user-clone-def projection absorbs
575 /// the instance's bindings).
576 /// - `is_folded = false` → key is the instance id (template-instance
577 /// projection is its own row).
578 /// Returns `None` if the instance no longer exists in
579 /// `db_agent_instances` (e.g. already deleted by FK cascade).
580 fn agents_projection_key_for_inst(
581 conn: &Connection,
582 inst_id: &str,
583 ) -> Option<(String, bool)> {
584 // Codex P2 on PR #1110: continuations of template-instances
585 // must resolve to the chain HEAD's id, not the continuation's
586 // own id — there's exactly one db_agents row per logical
587 // agent, keyed by the head. Walk up `parent_instance_id` via
588 // a recursive CTE; the row whose parent is `''` (or whose
589 // parent no longer exists) is the root.
590 //
591 // For user-clone defs (`is_seeded = 0`) the projection key is
592 // the def.id regardless of where in the chain we are — the
593 // existing one-row-per-def behavior is unchanged. Only the
594 // is_seeded=1 (template) branch needs the chain walk.
595 conn.query_row(
596 "WITH RECURSIVE chain(id, parent_instance_id, definition_id) AS (
597 SELECT id, parent_instance_id, definition_id
598 FROM db_agent_instances WHERE id = ?1
599 UNION ALL
600 SELECT p.id, p.parent_instance_id, p.definition_id
601 FROM db_agent_instances p
602 JOIN chain c ON p.id = c.parent_instance_id
603 )
604 SELECT c.id, c.definition_id, COALESCE(d.is_seeded, 1) AS is_seeded
605 FROM chain c
606 LEFT JOIN db_agent_definitions d ON c.definition_id = d.id
607 WHERE c.parent_instance_id = ''
608 OR NOT EXISTS (
609 SELECT 1 FROM db_agent_instances q
610 WHERE q.id = c.parent_instance_id
611 )
612 LIMIT 1",
613 params![inst_id],
614 |row| {
615 let root_id: String = row.get(0)?;
616 let def_id: String = row.get(1)?;
617 let is_seeded: i64 = row.get(2)?;
618 Ok(if is_seeded == 0 {
619 (def_id, true) // folded into def-projection
620 } else {
621 (root_id, false) // chain head's row
622 })
623 },
624 )
625 .ok()
626 }
627
628 /// Helper: re-read a definition row from inside an active connection
629 /// lock (used by `agents_dual_write_instance_create` to avoid
630 /// re-locking the mutex recursively).
631 fn load_definition_for_dual_write(
632 conn: &Connection,
633 id: &str,
634 ) -> rusqlite::Result<Option<AgentDefinition>> {
635 let mut stmt = conn.prepare(
636 "SELECT id, slug, name, icon, provider, description,
637 working_directory, shell, provider_flags, auto_start,
638 restart_on_crash, idle_timeout_minutes, created_at,
639 agent_type, environment, agent_bus_id, is_seeded,
640 accounts, parent_id, branch_label, updated_at,
641 user_hidden, container_image, container_volumes, container_name
642 FROM db_agent_definitions WHERE id = ?1",
643 )?;
644 let result = stmt.query_row(params![id], |row| {
645 Ok(AgentDefinition {
646 id: row.get(0)?,
647 slug: row.get(1)?,
648 name: row.get(2)?,
649 icon: row.get(3)?,
650 provider: row.get(4)?,
651 description: row.get(5)?,
652 working_directory: row.get(6)?,
653 shell: row.get(7)?,
654 provider_flags: row.get(8)?,
655 auto_start: row.get(9)?,
656 restart_on_crash: row.get(10)?,
657 idle_timeout_minutes: row.get(11)?,
658 created_at: row.get(12)?,
659 agent_type: row.get(13)?,
660 environment: row.get(14)?,
661 agent_bus_id: row.get(15)?,
662 is_seeded: row.get(16)?,
663 accounts: row.get(17)?,
664 parent_id: row.get(18)?,
665 branch_label: row.get(19)?,
666 updated_at: row.get(20)?,
667 user_hidden: row.get(21)?,
668 container_image: row.get(22)?,
669 container_volumes: row.get(23)?,
670 container_name: row.get(24)?,
671 })
672 });
673 match result {
674 Ok(d) => Ok(Some(d)),
675 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
676 Err(e) => Err(e),
677 }
678 }
679}